Skip to content

fix(export): mix all source audio tracks so the mic isn't dropped#109

Open
barnaclebarnes wants to merge 1 commit into
getopenscreen:mainfrom
barnaclebarnes:fix/export-mixes-all-audio-tracks
Open

fix(export): mix all source audio tracks so the mic isn't dropped#109
barnaclebarnes wants to merge 1 commit into
getopenscreen:mainfrom
barnaclebarnes:fix/export-mixes-all-audio-tracks

Conversation

@barnaclebarnes

@barnaclebarnes barnaclebarnes commented Jul 17, 2026

Copy link
Copy Markdown

Problem

Native macOS recordings write system audio and the microphone as two separate AAC tracks in the screen recording, both flagged default. The exporter decoded audio through web-demuxer's bare "audio" selector, which resolves to the single stream FFmpeg's av_find_best_stream picks — the first (system-audio) track. When nothing was playing through the system, that track is silent, so the exported video had no audible audio even though the microphone was recorded fine.

The browser/MediaRecorder path already blends system + mic into one track (audioMix.ts); the native macOS path never did, and the exporter assumed a single audio track.

Repro (from a real affected recording): the second track holds the voice, but av_find_best_stream selects the silent first track:

Stream #0:1 -> #0:1  (aac, 2 kb/s, mean_volume -91 dB)   ← selected, silent
Stream #0:2          (aac, 100 kb/s, mean_volume -31 dB) ← the mic, dropped

Fix

Decode every audio stream and mix them into one timeline before encoding, mirroring the browser recorder:

  • mixPlanarSources (pure, unit-tested) — sums each decoded source, downmixed to the target channels and aligned at its source-time offset, clamped to [-1, 1].
  • Per-stream decode via readAVPacket(streamIndex) targets each track by container index instead of the best-stream heuristic.
  • Both the trim-only and offline (speed) export paths mix multi-track sources. Multi-track speed projects are routed to the offline path because the real-time <audio> capture can only play one track.
  • The source-copy fast path is disabled for multi-track sources, since copying verbatim carries both tracks over and players fall back to the silent first one.
  • Single-track recordings keep the original fast path unchanged.

Testing

  • mixPlanarSources unit tests (sum, silent-track recovery, start-offset alignment, mono→stereo upmix, clamping).
  • New real-browser regression test (fixture: silent first track + 440 Hz tone) asserting the exported audio carries the tone through both the trim-only and speed/offline mixing paths.
  • Full suite green: 413 unit tests, browser suite, tsc, and Biome.
  • Verified end-to-end in a local dev build: exporting an affected recording now includes the microphone audio.

Closes #108

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Multi-track audio is now mixed correctly during video export.
    • Audio tracks are aligned, combined, channel-adjusted, and safely volume-limited.
    • Exports preserve audio even when one track is silent or when tracks begin at different times.
    • Source-copy optimization is automatically bypassed when multiple audio tracks require mixing.
  • Bug Fixes

    • Fixed exports that could omit audio by selecting only the first track.
    • Improved multi-track audio handling for speed-adjusted exports.

Native macOS recordings write system audio and the microphone as two
separate AAC tracks in the screen recording, and both are flagged
`default`. The exporter decoded audio through web-demuxer's bare "audio"
selector, which resolves to the single stream FFmpeg's
`av_find_best_stream` picks — the first (system-audio) track. When
nothing was playing, that track is silent, so the exported video had no
audible audio even though the mic was recorded fine. The browser
recorder already blends system + mic into one track; the native path
never did.

Decode every audio stream and mix them into one timeline before
encoding, mirroring the browser recorder:

- `mixPlanarSources` (pure, unit-tested) sums each decoded source,
  downmixed to the target channels and aligned at its source-time
  offset, clamped to [-1, 1].
- Per-stream decode via `readAVPacket(streamIndex)` targets each track
  by container index instead of the best-stream heuristic.
- The trim-only and offline (speed) export paths both mix multi-track
  sources; multi-track speed projects are routed to the offline path
  because the real-time <audio> capture can only play one track.
- The source-copy fast path is disabled for multi-track sources, since
  copying verbatim carries both tracks over and players fall back to the
  silent first one.

Single-track recordings keep the original fast path unchanged. Adds a
real-browser regression test (fixture: silent track + 440 Hz tone) that
asserts the exported audio carries the tone through both mixing paths.

Closes getopenscreen#108

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 17, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Multi-track audio export now decodes all audio streams, aligns and mixes their planar PCM, supports trim and speed-region paths, and disables source-copy export when mixing is required. Unit and browser tests validate mixed output and audible exports.

Changes

Multi-track audio export

Layer / File(s) Summary
Audio stream metadata and fast-path gating
src/lib/exporter/streamingDecoder.ts, src/lib/exporter/videoExporter.ts
Decoded metadata now includes the audio stream count, and source-copy export is blocked for inputs with multiple audio tracks.
Planar audio mixing and stream decoding
src/lib/exporter/audioEncoder.ts
Audio streams are decoded into timeline-aligned planar PCM, converted to the target channel count, summed, clamped, and encoded with sample-rate validation.
Export path integration
src/lib/exporter/audioEncoder.ts
Trim-only and speed-region paths use mixed audio buffers for multi-track inputs while retaining single-stream fallback behavior.
Mixing and browser export validation
src/lib/exporter/audioEncoder.test.ts, src/lib/exporter/audioMixExport.browser.test.ts, .gitignore
Tests cover mixing arithmetic and audible output through normal and speed-region exports; Vitest attachments are ignored.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant VideoExporter
  participant AudioProcessor
  participant WebDemuxer
  participant mixPlanarSources
  VideoExporter->>AudioProcessor: process source audio
  AudioProcessor->>WebDemuxer: enumerate and decode audio streams
  WebDemuxer-->>AudioProcessor: planar PCM with timeline offsets
  AudioProcessor->>mixPlanarSources: align and mix streams
  mixPlanarSources-->>AudioProcessor: mixed planar timeline
  AudioProcessor-->>VideoExporter: encoded export audio
Loading

Possibly related PRs

Suggested reviewers: etiennelescot

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main change: mixing all source audio tracks to preserve mic audio in exports.
Description check ✅ Passed The description covers the bug, fix, issue link, and testing, but it omits several template sections like type, impact, and screenshots.
Linked Issues check ✅ Passed The changes decode and mix all audio streams, route multi-track exports correctly, and add unit/browser coverage for the reported silent-track bug.
Out of Scope Changes check ✅ Passed The extra .gitignore update is test-artifact housekeeping and the rest of the changes directly support the audio-export fix.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/lib/exporter/audioEncoder.ts`:
- Around line 330-459: Replace the retained AudioData[] and full-track
planar-array workflow in decodeAudioStreamToPlanes and decodeMixedAudioPlanes
with bounded-window processing that decodes, mixes, and forwards audio
incrementally to the encoder/WSOLA pipeline. Avoid accumulating complete tracks
or duplicating samples in memory; preserve stream timeline offsets, channel
handling, cancellation, and sample-rate validation while ensuring long
recordings use bounded memory.
- Around line 437-439: Update mixPlanarSources() to return the sole decodable
source instead of null when sources.length is 1, while preserving the null
result for zero sources. Ensure the related caller paths around the
source-selection logic also retain and use this single-source result rather than
falling back to the demuxer’s best stream.
- Around line 388-399: Update the frame-alignment logic around startFrame in the
audio encoding flow to preserve the signed source timestamp; remove the clamp
that forces startFrame to zero. Keep mixPlanarSources() responsible for
discarding samples before frame zero so AAC preroll and timestamp-zero frames
retain their correct relative offsets.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 72ae16b3-89b6-4900-95d1-2691e7a3bdba

📥 Commits

Reviewing files that changed from the base of the PR and between d5966ed and 9920be7.

⛔ Files ignored due to path filters (1)
  • tests/fixtures/sample-dual-audio.mp4 is excluded by !**/*.mp4
📒 Files selected for processing (6)
  • .gitignore
  • src/lib/exporter/audioEncoder.test.ts
  • src/lib/exporter/audioEncoder.ts
  • src/lib/exporter/audioMixExport.browser.test.ts
  • src/lib/exporter/streamingDecoder.ts
  • src/lib/exporter/videoExporter.ts

Comment on lines +330 to +459
const decoded: AudioData[] = [];
const decoder = new AudioDecoder({
output: (data: AudioData) => decoded.push(data),
error: (e: DOMException) => console.error("[AudioProcessor] Decode error:", e),
});
decoder.configure(config);

const safeReadEnd =
typeof readEndSec === "number" && Number.isFinite(readEndSec) ? Math.max(0, readEndSec) : 0;
const packetStream = demuxer.readAVPacket(
0,
safeReadEnd,
AVMediaType.AVMEDIA_TYPE_AUDIO,
stream.index,
);
const reader = packetStream.getReader();
try {
while (!this.cancelled) {
const { done, value: packet } = await reader.read();
if (done || !packet) break;
decoder.decode(
new EncodedAudioChunk({
type: packet.keyframe === 1 ? "key" : "delta",
timestamp: Math.round(packet.timestamp * 1_000_000),
duration: Math.round(packet.duration * 1_000_000),
data: packet.data,
}),
);
while (decoder.decodeQueueSize > DECODE_BACKPRESSURE_LIMIT && !this.cancelled) {
await new Promise((resolve) => setTimeout(resolve, 1));
}
}
} finally {
try {
await reader.cancel();
} catch {
/* reader already closed */
}
}

if (decoder.state === "configured") {
if (!this.cancelled) await decoder.flush();
decoder.close();
}

if (this.cancelled || decoded.length === 0) {
for (const d of decoded) d.close();
return null;
}

// Decode order is normally monotonic; sort defensively so timeline placement
// stays correct even if the decoder emits out of order.
decoded.sort((a, b) => a.timestamp - b.timestamp);
const sampleRate = decoded[0].sampleRate;
const numberOfChannels = decoded[0].numberOfChannels;
// Absolute timeline position of this stream's first sample (mic tracks often
// start slightly after the video, e.g. start_time ~0.17s), so mixing keeps
// every track locked to the same source-time origin the video uses.
const startFrame = Math.max(0, Math.round((decoded[0].timestamp / 1_000_000) * sampleRate));

let totalFrames = 0;
for (const d of decoded) {
const frameOffset =
Math.round((d.timestamp / 1_000_000) * sampleRate) - startFrame + d.numberOfFrames;
if (frameOffset > totalFrames) totalFrames = frameOffset;
}

const planes = Array.from({ length: numberOfChannels }, () => new Float32Array(totalFrames));
for (const d of decoded) {
const at = Math.max(0, Math.round((d.timestamp / 1_000_000) * sampleRate) - startFrame);
const room = totalFrames - at;
if (room > 0) {
const take = Math.min(d.numberOfFrames, room);
for (let c = 0; c < numberOfChannels; c++) {
const temp = new Float32Array(d.numberOfFrames);
d.copyTo(temp, { format: "f32-planar", planeIndex: c });
planes[c].set(temp.subarray(0, take), at);
}
}
d.close();
}

return { planes, startFrame, sampleRate, numberOfChannels };
}

/**
* Decodes every audio stream and mixes them into one planar timeline (see
* {@link mixPlanarSources}). Returns null when there is at most one audio stream
* (callers keep the existing single-stream fast path) or when the streams can't
* be mixed (e.g. mismatched sample rates — never the case for native captures,
* which are all 48 kHz).
*/
private async decodeMixedAudioPlanes(
demuxer: WebDemuxer,
streams: WebAVStream[],
readEndSec: number | undefined,
outputChannels: number,
): Promise<{ planes: Float32Array[]; sampleRate: number; numberOfChannels: number } | null> {
if (streams.length < 2) return null;

const sources: Array<PlanarAudioSource & { sampleRate: number }> = [];
for (const stream of streams) {
if (this.cancelled) return null;
const decoded = await this.decodeAudioStreamToPlanes(demuxer, stream, readEndSec);
if (decoded) sources.push(decoded);
}

if (sources.length === 0) return null;
// A single decodable stream can't be "mixed" — fall back to the normal path.
if (sources.length === 1) return null;

const sampleRate = sources[0].sampleRate;
if (sources.some((s) => s.sampleRate !== sampleRate)) {
console.warn("[AudioProcessor] Audio streams differ in sample rate; skipping mix");
return null;
}

const channels = Math.min(2, Math.max(1, outputChannels || 2));
let totalFrames = 0;
for (const s of sources) {
const end = s.startFrame + (s.planes[0]?.length ?? 0);
if (end > totalFrames) totalFrames = end;
}
if (totalFrames === 0) return null;

const planes = mixPlanarSources(
sources.map((s) => ({ planes: s.planes, startFrame: s.startFrame })),
channels,
totalFrames,
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Stream the mix instead of retaining every decoded sample.

Each track is retained as AudioData[], copied into full planar arrays, then copied again into the final mix. At 48 kHz stereo, two one-hour tracks plus the mix exceed 4 GB before transient decoder storage, making long recordings likely to OOM. Decode and mix bounded windows directly into the encoder/WSOLA pipeline.

Also applies to: 1186-1200

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/exporter/audioEncoder.ts` around lines 330 - 459, Replace the
retained AudioData[] and full-track planar-array workflow in
decodeAudioStreamToPlanes and decodeMixedAudioPlanes with bounded-window
processing that decodes, mixes, and forwards audio incrementally to the
encoder/WSOLA pipeline. Avoid accumulating complete tracks or duplicating
samples in memory; preserve stream timeline offsets, channel handling,
cancellation, and sample-rate validation while ensuring long recordings use
bounded memory.

Comment on lines +388 to +399
const startFrame = Math.max(0, Math.round((decoded[0].timestamp / 1_000_000) * sampleRate));

let totalFrames = 0;
for (const d of decoded) {
const frameOffset =
Math.round((d.timestamp / 1_000_000) * sampleRate) - startFrame + d.numberOfFrames;
if (frameOffset > totalFrames) totalFrames = frameOffset;
}

const planes = Array.from({ length: numberOfChannels }, () => new Float32Array(totalFrames));
for (const d of decoded) {
const at = Math.max(0, Math.round((d.timestamp / 1_000_000) * sampleRate) - startFrame);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Preserve negative source timestamps.

Clamping startFrame to zero makes AAC preroll and the following timestamp-zero frame both write at offset zero, overwriting samples and shifting the timeline. Keep the signed offset; mixPlanarSources() already discards samples before frame zero.

Proposed fix
-		const startFrame = Math.max(0, Math.round((decoded[0].timestamp / 1_000_000) * sampleRate));
+		const startFrame = Math.round((decoded[0].timestamp / 1_000_000) * sampleRate);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const startFrame = Math.max(0, Math.round((decoded[0].timestamp / 1_000_000) * sampleRate));
let totalFrames = 0;
for (const d of decoded) {
const frameOffset =
Math.round((d.timestamp / 1_000_000) * sampleRate) - startFrame + d.numberOfFrames;
if (frameOffset > totalFrames) totalFrames = frameOffset;
}
const planes = Array.from({ length: numberOfChannels }, () => new Float32Array(totalFrames));
for (const d of decoded) {
const at = Math.max(0, Math.round((d.timestamp / 1_000_000) * sampleRate) - startFrame);
const startFrame = Math.round((decoded[0].timestamp / 1_000_000) * sampleRate);
let totalFrames = 0;
for (const d of decoded) {
const frameOffset =
Math.round((d.timestamp / 1_000_000) * sampleRate) - startFrame + d.numberOfFrames;
if (frameOffset > totalFrames) totalFrames = frameOffset;
}
const planes = Array.from({ length: numberOfChannels }, () => new Float32Array(totalFrames));
for (const d of decoded) {
const at = Math.max(0, Math.round((d.timestamp / 1_000_000) * sampleRate) - startFrame);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/exporter/audioEncoder.ts` around lines 388 - 399, Update the
frame-alignment logic around startFrame in the audio encoding flow to preserve
the signed source timestamp; remove the clamp that forces startFrame to zero.
Keep mixPlanarSources() responsible for discarding samples before frame zero so
AAC preroll and timestamp-zero frames retain their correct relative offsets.

Comment on lines +437 to +439
if (sources.length === 0) return null;
// A single decodable stream can't be "mixed" — fall back to the normal path.
if (sources.length === 1) return null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Do not discard the only decodable audio track.

When one of multiple streams fails decoding, the successfully decoded source is discarded and both callers fall back to the demuxer's best stream. If that stream is unsupported or silent, usable microphone audio is lost. mixPlanarSources() supports one source, so retain it.

Proposed fix
 		if (sources.length === 0) return null;
-		// A single decodable stream can't be "mixed" — fall back to the normal path.
-		if (sources.length === 1) return null;

Also applies to: 631-645, 1008-1032

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/exporter/audioEncoder.ts` around lines 437 - 439, Update
mixPlanarSources() to return the sole decodable source instead of null when
sources.length is 1, while preserving the null result for zero sources. Ensure
the related caller paths around the source-selection logic also retain and use
this single-source result rather than falling back to the demuxer’s best stream.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Export fails to export audio

1 participant